Add SLH-DSA Support - #455
Conversation
0487400 to
02cbe8b
Compare
8bf2ba2 to
ba88c76
Compare
Signed-off-by: Aidan Garske <aidan@wolfssl.com>
Frauschi
left a comment
There was a problem hiding this comment.
🐺 Skoll Code Review
Overall recommendation: REQUEST_CHANGES
Findings: 12 total — 5 posted, 7 skipped
Posted findings
- [High] openssl_example.c rewrite hard-fails on the IDE/Android and IDE/XCODE flows —
examples/openssl_example.c:47-75 (duplicated in examples/pqc_openssl_example.c:225-238) - [Medium] KAT vector accounting: the verify-only SLH-DSA KAT can pass with zero vectors, the exact 3099 count is pinned against a floating OpenSSL tag, and the old 2602 comment was left behind —
.github/workflows/wolfssl-versions-pqc.yml:204-214 - [Medium] WOLFSSL_SLHDSA_CONFIG is missing from the dependency cache key, undocumented in --help, and contradicted by INTEGRATION_GUIDE —
scripts/utils-wolfssl.sh:64-70 - [Medium] Example build gating and portability: executableDir may trip -Wmaybe-uninitialized, and BUILD_PQC_EXAMPLE is true for verify-only SLH-DSA builds where the example always fails —
examples/openssl_example.c:62-75; examples/pqc_openssl_example.c:225-238 - [Low] wp_mlkem_kem.c doc comment no longer matches the code it documents —
src/wp_mlkem_kem.c:186
Skipped findings
- [High] libacvp PQC job likely cannot build runtest, so the whole workflow may no-op
- [Medium] SLH-DSA test coverage gaps: four guard paths and the deterministic-keygen seed length validation are unexercised
- [Low] CI workflow hygiene: example steps run before the dependency push, nginx-pqc adds per-workflow apt installs, and test.sh defaults WOLFPROV_ROOT to a path nothing produces
- [Low] wp_slhdsa_key_types uses opaque index arithmetic into a flat parameter array
- [Low] test_slhdsa.c hygiene: randomised input described as deterministic, a verify context initialised with sign-only params, mixed XMEM*/libc string calls, and redundant double cleanup
- [Low] SLH-DSA locking contract is looser than the mutex implies: the key mutex is held across the full multi-second sign and across thread-local CheckKey work, wp_slhdsa_get_key() hands out a raw pointer to guarded state, and construction-time hasPub/hasPriv writes are unlocked
- [Info] settings.h pulls a wolfSSL algorithm header into every translation unit
Review generated by Skoll via Claude/Codex
Frauschi
left a comment
There was a problem hiding this comment.
🐺 Skoll Code Review
Overall recommendation: APPROVE
Findings: 16 total — 7 posted, 9 skipped
Posted findings
- [Medium] SLH-DSA verify-only CI row is indistinguishable from the plain slhdsa row and cannot detect a lost WOLFSSL_SLHDSA_CONFIG —
.github/workflows/wolfssl-versions-pqc.yml:183-191 - [Medium] wc_SlhDsaKey_CheckKey runs twice on every SLH-DSA keypair import —
src/wp_slhdsa_kmgmt.c:854,895 - [Low] wp_slhdsa_validate holds keyMutex across a full key copy and wc_SlhDsaKey_CheckKey —
src/wp_slhdsa_kmgmt.c:745-795 - [Low] nginx-pqc installs packages that are already baked into the test-deps image, against that image's stated policy —
.github/workflows/nginx-pqc.yml:132-136 - [Low] Missing cast on OPENSSL_malloc return —
src/wp_slhdsa_kmgmt.c:766 - [Low]
if (dupPub)is not guarded byokwhile the adjacentif (ok && dupPriv)is —src/wp_slhdsa_kmgmt.c:455 - [Low]
word32 sigSz;declared uninitialized in wp_slhdsa_sign —src/wp_slhdsa_sig.c:344
Skipped findings
- [Low] run_vector_family silently mutates its caller's
localcounters via bash dynamic scoping - [Low] 7 of 12 SLH-DSA AlgorithmIdentifier OID arcs are never exercised by any test
- [Low] extra_key gains a stray trailing dash on 7 of 8 matrix rows
- [Low] wp_slhdsa_validate nests refMutex inside keyMutex and runs the full CheckKey inside the shared key's critical section
- [Low] wp_slhdsa_sign and wp_slhdsa_verify hold the per-key mutex across the entire multi-second wolfSSL SLH-DSA operation
- [Info] Do not 'fix' SLH-DSA's export_object / verify_message_final to match ML-DSA and ECX -- SLH-DSA is the correct one
- [Info] Examples gate on the configure macro WOLFPROV_HAVE_* rather than the resolved WP_HAVE_*
- [Info] encData cleansed with post-encryption length instead of allocation size
- [Info] Three sites write hasPub/hasPriv without keyMutex, contradicting the invariant documented at wp_slhdsa_kmgmt.c:271
Review generated by Skoll via Claude/Codex
| printf '%s\n' "$tests" | grep -q 'test_mldsa_sign_verify' && dsa=1 | ||
| echo "expect=${{ matrix.expect }} mlkem=$kem mldsa=$dsa" | ||
| printf '%s\n' "$tests" | \ | ||
| grep -qE 'test_slhdsa_(sign_verify|public_keymgmt)' && slh=1 |
There was a problem hiding this comment.
🟡 [Medium] SLH-DSA verify-only CI row is indistinguishable from the plain slhdsa row and cannot detect a lost WOLFSSL_SLHDSA_CONFIG
💡 SUGGEST test
The PR adds a matrix row {"name":"master (SLH-DSA verify-only)", "enable":"--enable-slhdsa", "slh-config":"yes,sha2,verify-only", "expect":"slhdsa-verify"} whose whole purpose is to prove the WP_HAVE_SLHDSA_PRIVATE-off code path builds and works. No step actually asserts the build is verify-only. Three independent reasons, all verified: (1) the presence check greps test_slhdsa_(sign_verify|public_keymgmt) and test/unit.c:598 registers test_slhdsa_public_keymgmt outside the WP_HAVE_SLHDSA_PRIVATE guard, so the alternation matches in both builds and slh=1 either way; (2) the slhdsa and slhdsa-verify case arms are byte-identical ([ "$kem" = 0 ] && [ "$dsa" = 0 ] && [ "$slh" = 1 ]); (3) the new "Run SLH-DSA verify-only KAT" step runs evppkey_slh_dsa_sigver.txt, which passes on a full build too. If WOLFSSL_SLHDSA_CONFIG ever stops propagating through build-wolfprovider.sh -> utils-wolfssl.sh, this row silently degrades into a duplicate of the plain --enable-slhdsa row and stays green, leaving the entire verify-only path untested with no signal. That path is a substantial amount of new conditional code -- the omitted GEN_/SIGN_ dispatch entries, the #else branches in wp_slhdsa_dup/wp_slhdsa_match/wp_slhdsa_import/wp_slhdsa_export, and the whole IMPLEMENT_SLHDSA_PRIVATE_ENCODERS block.
Suggestion: Track private ops separately so the two rows assert different things:
slhpriv=0
printf '%s\n' "$tests" | grep -q 'test_slhdsa_sign_verify' && slhpriv=1
...
slhdsa) [ "$kem" = 0 ] && [ "$dsa" = 0 ] && [ "$slh" = 1 ] && [ "$slhpriv" = 1 ] ;;
slhdsa-verify) [ "$kem" = 0 ] && [ "$dsa" = 0 ] && [ "$slh" = 1 ] && [ "$slhpriv" = 0 ] ;;
Or assert on the build directly:
grep -q WOLFSSL_SLHDSA_VERIFY_ONLY wolfssl-install/include/wolfssl/options.h
Recommendation: Make the two rows assert different observable states, so a regression in the verify-only build configuration fails CI instead of silently collapsing the row into a duplicate.
| if (rc != 0) { | ||
| ok = 0; | ||
| } | ||
| if (ok && (wc_SlhDsaKey_CheckKey(&slhdsa->key) != 0)) { |
There was a problem hiding this comment.
🟡 [Medium] wc_SlhDsaKey_CheckKey runs twice on every SLH-DSA keypair import
💡 SUGGEST bug
For an EVP_PKEY_fromdata(..., EVP_PKEY_KEYPAIR, ...) import carrying both OSSL_PKEY_PARAM_PRIV_KEY and OSSL_PKEY_PARAM_PUB_KEY, wc_SlhDsaKey_CheckKey() is invoked twice on the same key: once immediately after wc_SlhDsaKey_ImportPrivate (line 854), and again in the "public import must remain consistent" block (line 895), because pubData != NULL and WC_SLHDSA_FLAG_PRIVATE is set by then. wc_SlhDsaKey_CheckKey re-derives PK.root from SK.seed, the single most expensive operation on an SLH-DSA key -- for the s parameter sets a full top-layer hypertree computation. The second call strictly subsumes the first (it validates the private against the actually-supplied public), so the first is duplicated work whenever a public component is also being imported. test_slhdsa_import_export_roundtrip exercises exactly this path for every entry in slhdsa_sets[], including SLH-DSA-SHA2-128s, doubling the cost of an already-slow test.
Suggestion: Skip the first check when a public key will also be imported, preserving it for the private-only case that test_slhdsa_validate_keypair relies on:
if (ok && ((selection & OSSL_KEYMGMT_SELECT_PUBLIC_KEY) == 0) &&
(wc_SlhDsaKey_CheckKey(&slhdsa->key) != 0)) {
ok = 0;
}
Recommendation: Gate the post-ImportPrivate CheckKey on the public key not being part of this import, so a keypair import pays for one root recomputation instead of two.
|
|
||
| ok = wolfssl_prov_is_running() && (slhdsa != NULL) && | ||
| ((selection & WP_SLHDSA_POSSIBLE_SELECTIONS) != 0); | ||
| if (ok && (wp_lock(wp_slhdsa_get_mutex((wp_SlhDsa*)slhdsa)) != 1)) { |
There was a problem hiding this comment.
🔵 [Low] wp_slhdsa_validate holds keyMutex across a full key copy and wc_SlhDsaKey_CheckKey
💡 SUGGEST bug
The full-check path takes the source key's keyMutex at line 745 and releases it only at 794. In between it allocates a second wp_SlhDsa via wp_slhdsa_new (which runs wc_SlhDsaKey_Init and initialises two mutexes), exports the private key to a heap buffer, re-imports it into the copy, runs wc_SlhDsaKey_CheckKey on the copy, and frees the copy. This is not a deadlock -- mutexes are per-object and wp_slhdsa_free(copy) only takes copy->refMutex -- but the lock is held across the most expensive FIPS 205 operation there is, so every concurrent sign, verify, or get_params on the same EVP_PKEY blocks for the full duration of an EVP_PKEY_check(). That matters specifically because this PR introduced the mutex to "serialize operations that share mutable wolfSSL key state"; nothing after the export touches shared state on the source key, so the lock does not need to cover the expensive part.
Suggestion: Snapshot the private key under the lock, release it, then do new/ImportPrivate/CheckKey/free on the copy outside:
/* export priv under keyMutex */
wp_unlock(wp_slhdsa_get_mutex((wp_SlhDsa*)slhdsa));
locked = 0;
/* copy + CheckKey here, lock-free */
Recommendation: Move the copy/import/CheckKey sequence outside the critical section -- only the ExportPrivate needs the lock. If the copy exists because wc_SlhDsaKey_CheckKey mutates the key, say so in the comment, since every other call site in the file (lines 854, 895, 1833) calls it on the live key.
| # Build the oqs-demos nginx image with wolfProvider in place of | ||
| # oqs-provider. nginx is pinned to release-1.28.0 in the Dockerfile. | ||
| - name: Build quantum-safe nginx image | ||
| - name: Install nginx build dependencies |
There was a problem hiding this comment.
🔵 [Low] nginx-pqc installs packages that are already baked into the test-deps image, against that image's stated policy
💡 SUGGEST convention
The Docker-to-container migration added a step running apt-get update && apt-get install -y libpcre3-dev zlib1g-dev inside ghcr.io/wolfssl/wolfprovider-test-deps:bookworm. Both packages already ship in that image: docker/wolfprovider-test-deps/Dockerfile:43 (zlib1g zlib1g-dev) and :58 (libpcre2-dev libpcre3-dev). That file states the policy explicitly at lines 4 and 13-14: "Goal: zero apt-get update calls during PR CI" and "Anything you add here ships in the image; do not add per-workflow installs." The step adds an avoidable apt round-trip and a network-flake failure mode to every matrix row. The sibling libacvp-pqc.yml added in this same PR correctly installs nothing.
Suggestion: Delete the step entirely.
Recommendation: Drop the step; both packages are already present. If something is genuinely missing, add it to docker/wolfprovider-test-deps/Dockerfile per that file's policy.
| } | ||
| if (ok) { | ||
| privLen = privAllocLen = slhdsa->data->privKeySize; | ||
| priv = OPENSSL_malloc(privAllocLen); |
There was a problem hiding this comment.
🔵 [Low] Missing cast on OPENSSL_malloc return
🔧 NIT convention
Every other allocation in this file casts the OPENSSL_malloc return (lines 457, 482, 646, 647, 683, 684, 1023, 1042, 1943, 2006), as does ML-DSA throughout. Line 766 is the only one that does not.
Suggestion:
| priv = OPENSSL_malloc(privAllocLen); | |
| priv = (unsigned char*)OPENSSL_malloc(privAllocLen); |
Recommendation: Add the cast for consistency with the rest of the file.
| ok = 0; | ||
| } | ||
|
|
||
| if (dupPub) { |
There was a problem hiding this comment.
🔵 [Low] if (dupPub) is not guarded by ok while the adjacent if (ok && dupPriv) is
🔧 NIT style
When wp_slhdsa_new returns NULL, ok is set to 0 but control still enters the if (dupPub) block and allocates pubBuf. No crash results -- the two inner if (ok) guards prevent &dst->key from ever being evaluated, and OPENSSL_free(pubBuf) cleans up -- but the block reads as though dst were valid, and the asymmetry with the if (ok && dupPriv) block directly below invites a future edit that dereferences dst unguarded.
Suggestion:
| if (dupPub) { | |
| if (ok && dupPub) { |
Recommendation: Add the ok && guard so both branches read identically and no allocation happens on the already-failed path.
| int ok = 1; | ||
| int rc = -1; | ||
| int locked = 0; | ||
| word32 sigSz; |
There was a problem hiding this comment.
🔵 [Low] word32 sigSz; declared uninitialized in wp_slhdsa_sign
🔧 NIT style
sigSz is assigned only inside if (ok) at line 374 and read only inside if (ok && ...) at lines 377, 384 and 400. The correlation makes every read safe in practice, but it is not one a compiler can prove, and this repo builds with -Wall -Wextra where GCC's -Wmaybe-uninitialized regularly fires on exactly this ok-flag pattern. Every other local in the function (ok, rc, locked, dummy) is initialised at declaration.
Suggestion:
| word32 sigSz; | |
| word32 sigSz = 0; |
Recommendation: Initialise to 0 for consistency with the surrounding declarations and to keep the build warning-clean across compiler versions.
Description